/*
 * Copyright 2009 Google Inc.
 * 
 * Licensed under the Apache License, Version 2.0 (the "License"); you may not
 * use this file except in compliance with the License. You may obtain a copy of
 * the License at
 * 
 * http://www.apache.org/licenses/LICENSE-2.0
 * 
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
 * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
 * License for the specific language governing permissions and limitations under
 * the License.
 */
package com.google.gwt.user.client.ui;

import java.util.Collection;
import java.util.List;

import com.google.gwt.event.dom.client.HandlesAllKeyEvents;
import com.google.gwt.event.dom.client.HasAllKeyHandlers;
import com.google.gwt.event.dom.client.KeyCodes;
import com.google.gwt.event.dom.client.KeyDownEvent;
import com.google.gwt.event.dom.client.KeyDownHandler;
import com.google.gwt.event.dom.client.KeyPressEvent;
import com.google.gwt.event.dom.client.KeyPressHandler;
import com.google.gwt.event.dom.client.KeyUpEvent;
import com.google.gwt.event.dom.client.KeyUpHandler;
import com.google.gwt.event.logical.shared.HasSelectionHandlers;
import com.google.gwt.event.logical.shared.SelectionEvent;
import com.google.gwt.event.logical.shared.SelectionHandler;
import com.google.gwt.event.logical.shared.ValueChangeEvent;
import com.google.gwt.event.logical.shared.ValueChangeHandler;
import com.google.gwt.event.shared.HandlerRegistration;
import com.google.gwt.user.client.Command;
import com.google.gwt.user.client.DOM;
import com.google.gwt.user.client.ui.PopupPanel.AnimationType;
import com.google.gwt.user.client.ui.SuggestOracle.Callback;
import com.google.gwt.user.client.ui.SuggestOracle.Request;
import com.google.gwt.user.client.ui.SuggestOracle.Response;
import com.google.gwt.user.client.ui.SuggestOracle.Suggestion;

/**
 * A {@link SuggestBox} is a text box or text area which displays a pre-configured set of selections
 * that match the user's input. Each {@link SuggestBox} is associated with a single
 * {@link SuggestOracle}. The {@link SuggestOracle} is used to provide a set of selections given a
 * specific query string.
 * <p>
 * By default, the {@link SuggestBox} uses a {@link MultiWordSuggestOracle} as its oracle. Below we
 * show how a {@link MultiWordSuggestOracle} can be configured:
 * </p>
 * 
 * <pre>
 * MultiWordSuggestOracle oracle = new MultiWordSuggestOracle();
 * oracle.add(&quot;Cat&quot;);
 * oracle.add(&quot;Dog&quot;);
 * oracle.add(&quot;Horse&quot;);
 * oracle.add(&quot;Canary&quot;);
 * 
 * SuggestBox box = new SuggestBox(oracle);
 * </pre>
 * 
 * Using the example above, if the user types "C" into the text widget, the oracle will configure
 * the suggestions with the "Cat" and "Canary" suggestions. Specifically, whenever the user types a
 * key into the text widget, the value is submitted to the <code>MultiWordSuggestOracle</code>.
 * <p>
 * Note that there is no method to retrieve the "currently selected suggestion" in a SuggestBox,
 * because there are points in time where the currently selected suggestion is not defined. For
 * example, if the user types in some text that does not match any of the SuggestBox's suggestions,
 * then the SuggestBox will not have a currently selected suggestion. It is more useful to know when
 * a suggestion has been chosen from the SuggestBox's list of suggestions. A SuggestBox fires
 * {@link SuggestionEvent SuggestionEvents} whenever a suggestion is chosen, and handlers for these
 * events can be added using the {@link #addValueChangeHandler(ValueChangeHandler)} method.
 * </p>
 * <p>
 * <img class='gallery' src='SuggestBox.png'/>
 * </p>
 * <h3>CSS Style Rules</h3>
 * <dl>
 * <dt>.gwt-SuggestBox</dt>
 * <dd>the suggest box itself</dd>
 * <dt>.gwt-SuggestBoxPopup</dt>
 * <dd>the suggestion popup</dd>
 * <dt>.gwt-SuggestBoxPopup .item</dt>
 * <dd>an unselected suggestion</dd>
 * <dt>.gwt-SuggestBoxPopup .item-selected</dt>
 * <dd>a selected suggestion</dd>
 * <dt>.gwt-SuggestBoxPopup .suggestPopupTopLeft</dt>
 * <dd>the top left cell</dd>
 * <dt>.gwt-SuggestBoxPopup .suggestPopupTopLeftInner</dt>
 * <dd>the inner element of the cell</dd>
 * <dt>.gwt-SuggestBoxPopup .suggestPopupTopCenter</dt>
 * <dd>the top center cell</dd>
 * <dt>.gwt-SuggestBoxPopup .suggestPopupTopCenterInner</dt>
 * <dd>the inner element of the cell</dd>
 * <dt>.gwt-SuggestBoxPopup .suggestPopupTopRight</dt>
 * <dd>the top right cell</dd>
 * <dt>.gwt-SuggestBoxPopup .suggestPopupTopRightInner</dt>
 * <dd>the inner element of the cell</dd>
 * <dt>.gwt-SuggestBoxPopup .suggestPopupMiddleLeft</dt>
 * <dd>the middle left cell</dd>
 * <dt>.gwt-SuggestBoxPopup .suggestPopupMiddleLeftInner</dt>
 * <dd>the inner element of the cell</dd>
 * <dt>.gwt-SuggestBoxPopup .suggestPopupMiddleCenter</dt>
 * <dd>the middle center cell</dd>
 * <dt>.gwt-SuggestBoxPopup .suggestPopupMiddleCenterInner</dt>
 * <dd>the inner element of the cell</dd>
 * <dt>.gwt-SuggestBoxPopup .suggestPopupMiddleRight</dt>
 * <dd>the middle right cell</dd>
 * <dt>.gwt-SuggestBoxPopup .suggestPopupMiddleRightInner</dt>
 * <dd>the inner element of the cell</dd>
 * <dt>.gwt-SuggestBoxPopup .suggestPopupBottomLeft</dt>
 * <dd>the bottom left cell</dd>
 * <dt>.gwt-SuggestBoxPopup .suggestPopupBottomLeftInner</dt>
 * <dd>the inner element of the cell</dd>
 * <dt>.gwt-SuggestBoxPopup .suggestPopupBottomCenter</dt>
 * <dd>the bottom center cell</dd>
 * <dt>.gwt-SuggestBoxPopup .suggestPopupBottomCenterInner</dt>
 * <dd>the inner element of the cell</dd>
 * <dt>.gwt-SuggestBoxPopup .suggestPopupBottomRight</dt>
 * <dd>the bottom right cell</dd>
 * <dt>.gwt-SuggestBoxPopup .suggestPopupBottomRightInner</dt>
 * <dd>the inner element of the cell</dd>
 * </dl>
 * 
 * @see SuggestOracle
 * @see MultiWordSuggestOracle
 * @see TextBoxBase
 */
@SuppressWarnings("deprecation")
public class SuggestBox extends Composite implements HasText, HasFocus, HasAnimation,
        SourcesClickEvents, SourcesFocusEvents, SourcesChangeEvents, SourcesKeyboardEvents,
        FiresSuggestionEvents, HasAllKeyHandlers, HasValue<String>,
        HasSelectionHandlers<Suggestion> {

	/**
	 * The SuggestionMenu class is used for the display and selection of suggestions in the
	 * SuggestBox widget. SuggestionMenu differs from MenuBar in that it always has a vertical
	 * orientation, and it has no submenus. It also allows for programmatic selection of items in
	 * the menu, and programmatically performing the action associated with the selected item. In
	 * the MenuBar class, items cannot be selected programatically - they can only be selected when
	 * the user places the mouse over a particlar item. Additional methods in SuggestionMenu provide
	 * information about the number of items in the menu, and the index of the currently selected
	 * item.
	 */
	protected static class SuggestionMenu extends MenuBar {

		public SuggestionMenu(boolean vertical) {
			super(vertical);
			// Make sure that CSS styles specified for the default Menu classes
			// do not affect this menu
			setStyleName("");
		}

		public void doSelectedItemAction() {
			// In order to perform the action of the item that is currently
			// selected, the menu must be showing.
			MenuItem selectedItem = getSelectedItem();
			if (selectedItem != null) {
				doItemAction(selectedItem, true);
			}
		}

		public int getNumItems() {
			return getItems().size();
		}

		/**
		 * Returns the index of the menu item that is currently selected.
		 * 
		 * @return returns the selected item
		 */
		public int getSelectedItemIndex() {
			// The index of the currently selected item can only be
			// obtained if the menu is showing.
			MenuItem selectedItem = getSelectedItem();
			if (selectedItem != null) {
				return getItems().indexOf(selectedItem);
			}
			return -1;
		}

		/**
		 * Selects the item at the specified index in the menu. Selecting the item does not perform
		 * the item's associated action; it only changes the style of the item and updates the value
		 * of SuggestionMenu.selectedItem.
		 * 
		 * @param index index
		 */
		public void selectItem(int index) {
			List<MenuItem> items = getItems();
			if (index > -1 && index < items.size()) {
				itemOver(items.get(index), false);
			}
		}
	}

	/**
	 * Class for menu items in a SuggestionMenu. A SuggestionMenuItem differs from a MenuItem in
	 * that each item is backed by a Suggestion object. The text of each menu item is derived from
	 * the display string of a Suggestion object, and each item stores a reference to its Suggestion
	 * object.
	 */
	private static class SuggestionMenuItem extends MenuItem {

		private static final String STYLENAME_DEFAULT = "item";

		private Suggestion suggestion;

		public SuggestionMenuItem(Suggestion suggestion, boolean asHTML) {
			super(suggestion.getDisplayString(), asHTML);
			// Each suggestion should be placed in a single row in the suggestion
			// menu. If the window is resized and the suggestion cannot fit on a
			// single row, it should be clipped (instead of wrapping around and
			// taking up a second row).
			DOM.setStyleAttribute(getElement(), "whiteSpace", "nowrap");
			setStyleName(STYLENAME_DEFAULT);
			setSuggestion(suggestion);
		}

		public Suggestion getSuggestion() {
			return suggestion;
		}

		public void setSuggestion(Suggestion suggestion) {
			this.suggestion = suggestion;
		}
	}

	private static final String STYLENAME_DEFAULT = "gwt-SuggestBox";

	private int limit = 20;
	private boolean selectsFirstItem = true;
	private SuggestOracle oracle;
	private String currentText;
	private final SuggestionMenu suggestionMenu;
	private final PopupPanel suggestionPopup;
	private final TextBoxBase box;
	private final Callback callback = new Callback() {
		public void onSuggestionsReady(Request request, Response response) {
			showSuggestions(response.getSuggestions());
		}
	};

	/**
	 * Constructor for {@link SuggestBox}. Creates a {@link MultiWordSuggestOracle} and
	 * {@link TextBox} to use with this {@link SuggestBox}.
	 */
	public SuggestBox() {
		this(new MultiWordSuggestOracle());
	}

	/**
	 * Constructor for {@link SuggestBox}. Creates a {@link TextBox} to use with this
	 * {@link SuggestBox}.
	 * 
	 * @param oracle the oracle for this <code>SuggestBox</code>
	 */
	public SuggestBox(SuggestOracle oracle) {
		this(oracle, new TextBox());
	}

	/**
	 * Constructor for {@link SuggestBox}. The text box will be removed from it's current location
	 * and wrapped by the {@link SuggestBox}.
	 * 
	 * @param oracle supplies suggestions based upon the current contents of the text widget
	 * @param box the text widget
	 */
	public SuggestBox(SuggestOracle oracle, TextBoxBase box) {
		this.box = box;
		initWidget(box);

		// suggestionMenu must be created before suggestionPopup, because
		// suggestionMenu is suggestionPopup's widget
		suggestionMenu = new SuggestionMenu(true);
		suggestionPopup = createPopup();
		suggestionPopup.setAnimationType(AnimationType.ROLL_DOWN);

		addEventsToTextBox();

		setOracle(oracle);
		setStyleName(STYLENAME_DEFAULT);
	}

	/**
	 * Adds a listener to receive change events on the SuggestBox's text box. The source Widget for
	 * these events will be the SuggestBox.
	 * 
	 * @param listener the listener interface to add
	 * @deprecated use getTextBox().addChangeHandler instead
	 */
	@Deprecated
	public void addChangeListener(final ChangeListener listener) {
		ListenerWrapper.WrappedLogicalChangeListener.add(box, listener).setSource(this);
	}

	/**
	 * Adds a listener to receive click events on the SuggestBox's text box. The source Widget for
	 * these events will be the SuggestBox.
	 * 
	 * @param listener the listener interface to add
	 * @deprecated use getTextBox().addClickHandler instead
	 */
	@Deprecated
	public void addClickListener(final ClickListener listener) {
		ListenerWrapper.WrappedClickListener legacy = ListenerWrapper.WrappedClickListener.add(box,
		        listener);
		legacy.setSource(this);
	}

	/**
	 * Adds an event to this handler.
	 * 
	 * @deprecated use addSelectionHandler instead.
	 */
	@Deprecated
	public void addEventHandler(final SuggestionHandler handler) {
		ListenerWrapper.WrappedOldSuggestionHandler.add(this, handler);
	}

	/**
	 * Adds a listener to receive focus events on the SuggestBox's text box. The source Widget for
	 * these events will be the SuggestBox.
	 * 
	 * @param listener the listener interface to add
	 * @deprecated use getTextBox().addFocusHandler/addBlurHandler() instead
	 */
	@Deprecated
	public void addFocusListener(final FocusListener listener) {
		ListenerWrapper.WrappedFocusListener focus = ListenerWrapper.WrappedFocusListener.add(box,
		        listener);
		focus.setSource(this);
	}

	@Deprecated
	public void addKeyboardListener(KeyboardListener listener) {
		ListenerWrapper.WrappedKeyboardListener.add(this, listener);
	}

	public HandlerRegistration addKeyDownHandler(KeyDownHandler handler) {
		return getWidget().addHandler(handler, KeyDownEvent.getType());
	}

	public HandlerRegistration addKeyPressHandler(KeyPressHandler handler) {
		return getWidget().addHandler(handler, KeyPressEvent.getType());
	}

	public HandlerRegistration addKeyUpHandler(KeyUpHandler handler) {
		return getWidget().addHandler(handler, KeyUpEvent.getType());
	}

	public HandlerRegistration addSelectionHandler(SelectionHandler<Suggestion> handler) {
		return getWidget().addHandler(handler, SelectionEvent.getType());
	}

	public HandlerRegistration addValueChangeHandler(ValueChangeHandler<String> handler) {
		return getWidget().addHandler(handler, ValueChangeEvent.getType());
	}

	/**
	 * Gets the limit for the number of suggestions that should be displayed for this box. It is up
	 * to the current {@link SuggestOracle} to enforce this limit.
	 * 
	 * @return the limit for the number of suggestions
	 */
	public int getLimit() {
		return limit;
	}

	/**
	 * Gets the suggest box's {@link com.google.gwt.user.client.ui.SuggestOracle}.
	 * 
	 * @return the {@link SuggestOracle}
	 */
	public SuggestOracle getSuggestOracle() {
		return oracle;
	}

	public int getTabIndex() {
		return box.getTabIndex();
	}

	public String getText() {
		return box.getText();
	}

	/**
	 * Get the text box associated with this suggest box.
	 * 
	 * @return this suggest box's text box
	 */
	public TextBoxBase getTextBox() {
		return box;
	}

	public String getValue() {
		return box.getValue();
	}

	/**
	 * Hide current suggestions.
	 */
	public void hideSuggestionList() {
		this.suggestionPopup.hide();
	}

	public boolean isAnimationEnabled() {
		return suggestionPopup.isAnimationEnabled();
	}

	/**
	 * Returns whether or not the first suggestion will be automatically selected. This behavior is
	 * on by default.
	 * 
	 * @return true if the first suggestion will be automatically selected
	 */
	public boolean isAutoSelectEnabled() {
		return selectsFirstItem;
	}

	/**
	 * @return true if the list of suggestions is currently showing, false if not
	 */
	public boolean isSuggestionListShowing() {
		return suggestionPopup.isShowing();
	}

	@Deprecated
	public void removeChangeListener(ChangeListener listener) {
		ListenerWrapper.WrappedChangeListener.remove(box, listener);
	}

	@Deprecated
	public void removeClickListener(ClickListener listener) {
		ListenerWrapper.WrappedClickListener.remove(box, listener);
	}

	@Deprecated
	public void removeEventHandler(SuggestionHandler handler) {
		ListenerWrapper.WrappedOldSuggestionHandler.remove(this, handler);
	}

	@Deprecated
	public void removeFocusListener(FocusListener listener) {
		ListenerWrapper.WrappedFocusListener.remove(this, listener);
	}

	@Deprecated
	public void removeKeyboardListener(KeyboardListener listener) {
		ListenerWrapper.WrappedKeyboardListener.remove(this, listener);
	}

	public void setAccessKey(char key) {
		box.setAccessKey(key);
	}

	public void setAnimationEnabled(boolean enable) {
		suggestionPopup.setAnimationEnabled(enable);
	}

	/**
	 * Turns on or off the behavior that automatically selects the first suggested item. This
	 * behavior is on by default.
	 * 
	 * @param selectsFirstItem Whether or not to automatically select the first suggestion
	 */
	public void setAutoSelectEnabled(boolean selectsFirstItem) {
		this.selectsFirstItem = selectsFirstItem;
	}

	public void setFocus(boolean focused) {
		box.setFocus(focused);
	}

	/**
	 * Sets the limit to the number of suggestions the oracle should provide. It is up to the oracle
	 * to enforce this limit.
	 * 
	 * @param limit the limit to the number of suggestions provided
	 */
	public void setLimit(int limit) {
		this.limit = limit;
	}

	/**
	 * Sets the style name of the suggestion popup.
	 * 
	 * @param style the new primary style name
	 * @see UIObject#setStyleName(String)
	 */
	public void setPopupStyleName(String style) {
		suggestionPopup.setStyleName(style);
	}

	public void setTabIndex(int index) {
		box.setTabIndex(index);
	}

	public void setText(String text) {
		box.setText(text);
	}

	public void setValue(String newValue) {
		box.setValue(newValue);
	}

	public void setValue(String value, boolean fireEvents) {
		box.setValue(value, fireEvents);
	}

	/**
	 * Show the current list of suggestions.
	 */
	public void showSuggestionList() {
		if (isAttached()) {
			currentText = null;
			refreshSuggestions();
		}
	}

	/**
	 * <b>Affected Elements:</b>
	 * <ul>
	 * <li>-popup = The popup that appears with suggestions.</li>
	 * <li>-items-item# = The suggested item at the specified index.</li>
	 * </ul>
	 * 
	 * @see UIObject#onEnsureDebugId(String)
	 */
	@Override
	protected void onEnsureDebugId(String baseID) {
		super.onEnsureDebugId(baseID);
		suggestionPopup.ensureDebugId(baseID + "-popup");
		getSuggestionMenu().setMenuItemDebugIds(baseID);
	}

	/**
	 * Gets the specified suggestion from the suggestions currently showing.
	 * 
	 * @param index the index at which the suggestion lives
	 * @throws IndexOutOfBoundsException if the index is greater then the number of suggestions
	 *             currently showing
	 * @return the given suggestion
	 */
	Suggestion getSuggestion(int index) {
		if (!isSuggestionListShowing()) {
			throw new IndexOutOfBoundsException("No suggestions showing, so cannot show " + index);
		}
		return ((SuggestionMenuItem) getSuggestionMenu().getItems().get(index)).suggestion;
	}

	/**
	 * Get the number of suggestions that are currently showing.
	 * 
	 * @return the number of suggestions currently showing, 0 if there are none
	 */
	int getSuggestionCount() {
		return isSuggestionListShowing() ? getSuggestionMenu().getNumItems() : 0;
	}

	void showSuggestions(String query) {
		if (query.length() == 0) {
			oracle.requestDefaultSuggestions(new Request(null, limit), callback);
		} else {
			oracle.requestSuggestions(new Request(query, limit), callback);
		}
	}

	private void addEventsToTextBox() {
		class TextBoxEvents extends HandlesAllKeyEvents implements ValueChangeHandler<String> {

			public void onKeyDown(KeyDownEvent event) {
				// Make sure that the menu is actually showing. These keystrokes
				// are only relevant when choosing a suggestion.
				if (suggestionPopup.isAttached()) {
					switch (event.getNativeKeyCode()) {
						case KeyCodes.KEY_DOWN:
							getSuggestionMenu().selectItem(
							        getSuggestionMenu().getSelectedItemIndex() + 1);
							break;
						case KeyCodes.KEY_UP:
							getSuggestionMenu().selectItem(
							        getSuggestionMenu().getSelectedItemIndex() - 1);
							break;
						case KeyCodes.KEY_ENTER:
						case KeyCodes.KEY_TAB:
							if (getSuggestionMenu().getSelectedItemIndex() < 0) {
								suggestionPopup.hide();
							} else {
								getSuggestionMenu().doSelectedItemAction();
							}
							break;
					}
				}
				delegateEvent(SuggestBox.this, event);
			}

			public void onKeyPress(KeyPressEvent event) {
				delegateEvent(SuggestBox.this, event);
			}

			public void onKeyUp(KeyUpEvent event) {
				// After every user key input, refresh the popup's suggestions.
				refreshSuggestions();
				delegateEvent(SuggestBox.this, event);
			}

			public void onValueChange(ValueChangeEvent<String> event) {
				delegateEvent(SuggestBox.this, event);
			}
		}

		TextBoxEvents events = new TextBoxEvents();
		events.addKeyHandlersTo(box);
		box.addValueChangeHandler(events);
	}

	private PopupPanel createPopup() {
		PopupPanel p = new DecoratedPopupPanel(true, false, "suggestPopup");
		p.setWidget(getSuggestionMenu());
		p.setStyleName("gwt-SuggestBoxPopup");
		p.setPreviewingAllNativeEvents(true);
		p.addAutoHidePartner(getTextBox().getElement());
		return p;
	}

	private void fireSuggestionEvent(Suggestion selectedSuggestion) {
		SelectionEvent.fire(this, selectedSuggestion);
	}

	private void refreshSuggestions() {
		// Get the raw text.
		String text = box.getText();
		if (text.equals(currentText)) {
			return;
		} else {
			currentText = text;
		}
		showSuggestions(text);
	}

	private void setNewSelection(SuggestionMenuItem menuItem) {
		Suggestion curSuggestion = menuItem.getSuggestion();
		currentText = curSuggestion.getReplacementString();
		setText(currentText);
		suggestionPopup.hide();
		fireSuggestionEvent(curSuggestion);
	}

	/**
	 * Sets the suggestion oracle used to create suggestions.
	 * 
	 * @param oracle the oracle
	 */
	private void setOracle(SuggestOracle oracle) {
		this.oracle = oracle;
	}

	/**
	 * Show the given collection of suggestions.
	 * 
	 * @param suggestions suggestions to show
	 */
	private void showSuggestions(Collection<? extends Suggestion> suggestions) {
		if (suggestions.size() > 0) {

			// Hide the popup before we manipulate the menu within it. If we do not
			// do this, some browsers will redraw the popup as items are removed
			// and added to the menu.
			boolean isAnimationEnabled = suggestionPopup.isAnimationEnabled();
			if (suggestionPopup.isAttached()) {
				suggestionPopup.hide();
			}

			getSuggestionMenu().clearItems();

			for (Suggestion curSuggestion : suggestions) {
				final SuggestionMenuItem menuItem = new SuggestionMenuItem(curSuggestion, oracle
				        .isDisplayStringHTML());
				menuItem.setCommand(new Command() {
					public void execute() {
						SuggestBox.this.setNewSelection(menuItem);
					}
				});

				getSuggestionMenu().addItem(menuItem);
			}

			if (selectsFirstItem) {
				// Select the first item in the suggestion menu.
				getSuggestionMenu().selectItem(0);
			}

			suggestionPopup.showRelativeTo(getTextBox());
			suggestionPopup.setAnimationEnabled(isAnimationEnabled);
		} else {
			suggestionPopup.hide();
		}
	}

	/**
	 * @return the suggestionMenu
	 */
	protected SuggestionMenu getSuggestionMenu() {
		return suggestionMenu;
	}
}
